| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495 |
- import { promises as fs, createReadStream } from 'fs';
- import path from 'path';
- import { NextRequest, NextResponse } from 'next/server';
- import { CLASSROOMS_DIR, isValidClassroomId } from '@/lib/server/classroom-storage';
- import { createLogger } from '@/lib/logger';
- const log = createLogger('ClassroomMedia');
- const MIME_TYPES: Record<string, string> = {
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.webp': 'image/webp',
- '.gif': 'image/gif',
- '.mp4': 'video/mp4',
- '.webm': 'video/webm',
- '.mp3': 'audio/mpeg',
- '.wav': 'audio/wav',
- '.ogg': 'audio/ogg',
- '.aac': 'audio/aac',
- };
- export async function GET(
- _req: NextRequest,
- { params }: { params: Promise<{ classroomId: string; path: string[] }> },
- ) {
- const { classroomId, path: pathSegments } = await params;
- // Validate classroomId
- if (!isValidClassroomId(classroomId)) {
- return NextResponse.json({ error: 'Invalid classroom ID' }, { status: 400 });
- }
- // Validate path segments — no traversal
- const joined = pathSegments.join('/');
- if (joined.includes('..') || pathSegments.some((s) => s.includes('\0'))) {
- return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
- }
- // Only allow media/ and audio/ subdirectories
- const subDir = pathSegments[0];
- if (subDir !== 'media' && subDir !== 'audio') {
- return NextResponse.json({ error: 'Invalid path' }, { status: 404 });
- }
- const filePath = path.join(CLASSROOMS_DIR, classroomId, ...pathSegments);
- const resolvedBase = path.resolve(CLASSROOMS_DIR, classroomId);
- try {
- // Resolve symlinks and verify the real path stays within the classroom dir
- const realPath = await fs.realpath(filePath);
- if (!realPath.startsWith(resolvedBase + path.sep) && realPath !== resolvedBase) {
- return NextResponse.json({ error: 'Not found' }, { status: 404 });
- }
- const stat = await fs.stat(realPath);
- if (!stat.isFile()) {
- return NextResponse.json({ error: 'Not found' }, { status: 404 });
- }
- const ext = path.extname(realPath).toLowerCase();
- const contentType = MIME_TYPES[ext] || 'application/octet-stream';
- // Stream the file to avoid loading large videos into memory
- const stream = createReadStream(realPath);
- const webStream = new ReadableStream({
- start(controller) {
- stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk));
- stream.on('end', () => controller.close());
- stream.on('error', (err) => controller.error(err));
- },
- cancel() {
- stream.destroy();
- },
- });
- return new NextResponse(webStream, {
- status: 200,
- headers: {
- 'Content-Type': contentType,
- 'Content-Length': String(stat.size),
- 'Cache-Control': 'public, max-age=86400, immutable',
- },
- });
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
- return NextResponse.json({ error: 'Not found' }, { status: 404 });
- }
- log.error(
- `Classroom media serving failed [classroomId=${classroomId}, path=${joined}]:`,
- error,
- );
- return NextResponse.json({ error: 'Internal error' }, { status: 500 });
- }
- }
|